improvements: improve approval tables UI and add additional functionality

This commit is contained in:
Scott Wilson
2025-06-19 09:39:42 -07:00
parent 7d90d183fb
commit 459b262865
12 changed files with 1327 additions and 919 deletions
@@ -60,6 +60,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
committerUser: approvalRequestUser, committerUser: approvalRequestUser,
commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(),
environment: z.string(), environment: z.string(),
secretPath: z.string(),
reviewers: z.object({ userId: z.string(), status: z.string() }).array(), reviewers: z.object({ userId: z.string(), status: z.string() }).array(),
approvers: z approvers: z
.object({ .object({
@@ -208,8 +208,21 @@ export const secretApprovalRequestServiceFactory = ({
}); });
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
const getSecretMapPath = async (folderIds: string[]) => {
const secretPaths = await folderDAL.findSecretPathByFolderIds(projectId, folderIds);
const secretPathMap: Record<string, string> = {};
secretPaths.forEach((folder) => {
if (folder) secretPathMap[folder.id] = folder.path;
});
return secretPathMap;
};
if (shouldUseSecretV2Bridge) { if (shouldUseSecretV2Bridge) {
return secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({ const approvalsV2 = await secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({
projectId, projectId,
committer, committer,
environment, environment,
@@ -218,7 +231,12 @@ export const secretApprovalRequestServiceFactory = ({
limit, limit,
offset offset
}); });
const secretPathMap = await getSecretMapPath([...new Set(approvalsV2.map((approval) => approval.folderId))]);
return approvalsV2.map((approval) => ({ ...approval, secretPath: secretPathMap[approval.folderId] }));
} }
const approvals = await secretApprovalRequestDAL.findByProjectId({ const approvals = await secretApprovalRequestDAL.findByProjectId({
projectId, projectId,
committer, committer,
@@ -228,7 +246,10 @@ export const secretApprovalRequestServiceFactory = ({
limit, limit,
offset offset
}); });
return approvals;
const secretPathMap = await getSecretMapPath([...new Set(approvals.map((approval) => approval.folderId))]);
return approvals.map((approval) => ({ ...approval, secretPath: secretPathMap[approval.folderId] }));
}; };
const getSecretApprovalDetails = async ({ const getSecretApprovalDetails = async ({
@@ -94,7 +94,7 @@ export const DropdownMenuItem = <T extends ElementType = "button">({
className={twMerge( className={twMerge(
"block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700", "block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700",
className, className,
isDisabled ? "pointer-events-none opacity-50" : "" isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : ""
)} )}
> >
<Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}> <Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}>
+2 -2
View File
@@ -2,11 +2,11 @@ import { PolicyType } from "@app/hooks/api/policies/enums";
export const policyDetails: Record<PolicyType, { name: string; className: string }> = { export const policyDetails: Record<PolicyType, { name: string; className: string }> = {
[PolicyType.AccessPolicy]: { [PolicyType.AccessPolicy]: {
className: "bg-lime-900 text-lime-100", className: "bg-yellow-500/40 text-mineshaft-100",
name: "Access Policy" name: "Access Policy"
}, },
[PolicyType.ChangePolicy]: { [PolicyType.ChangePolicy]: {
className: "bg-indigo-900 text-indigo-100", className: "bg-blue-500/40 text-mineshaft-100",
name: "Change Policy" name: "Change Policy"
} }
}; };
@@ -352,9 +352,9 @@ export const ProjectLayout = () => {
secretApprovalReqCount?.open || secretApprovalReqCount?.open ||
accessApprovalRequestCount?.pendingCount accessApprovalRequestCount?.pendingCount
) && ( ) && (
<span className="ml-2 rounded border border-primary-400 bg-primary-600 px-1 py-0.5 text-xs font-semibold text-black"> <Badge variant="primary" className="ml-1.5">
{pendingRequestsCount} {pendingRequestsCount}
</span> </Badge>
)} )}
</MenuItem> </MenuItem>
)} )}
@@ -1,7 +1,5 @@
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
@@ -45,21 +43,7 @@ export const SecretApprovalsPage = () => {
<PageHeader <PageHeader
title="Approval Workflows" title="Approval Workflows"
description="Create approval policies for any modifications to secrets in sensitive environments and folders." description="Create approval policies for any modifications to secrets in sensitive environments and folders."
> />
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</a>
</PageHeader>
<Tabs defaultValue={defaultTab}> <Tabs defaultValue={defaultTab}>
<TabList> <TabList>
<Tab value={TabSection.SecretApprovalRequests}> <Tab value={TabSection.SecretApprovalRequests}>
@@ -2,15 +2,21 @@
/* eslint-disable react/jsx-no-useless-fragment */ /* eslint-disable react/jsx-no-useless-fragment */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
faArrowUpRightFromSquare,
faBookOpen,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
faChevronDown, faChevronDown,
faLock, faLock,
faPlus faMagnifyingGlass,
faPlus,
faSearch,
faUser
} 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 { formatDistance } from "date-fns"; import { formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { import {
@@ -21,6 +27,7 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
Input,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
@@ -48,28 +55,21 @@ import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { RequestAccessModal } from "./components/RequestAccessModal"; import { RequestAccessModal } from "./components/RequestAccessModal";
import { ReviewAccessRequestModal } from "./components/ReviewAccessModal"; import { ReviewAccessRequestModal } from "./components/ReviewAccessModal";
const generateRequestText = (request: TAccessApprovalRequest, userId: string) => { const generateRequestText = (request: TAccessApprovalRequest) => {
const { isTemporary } = request; const { isTemporary } = request;
return ( return (
<div className="flex w-full items-center justify-between text-sm"> <div className="flex items-center justify-between">
<div> <div>
Requested {isTemporary ? "temporary" : "permanent"} access to{" "} Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary"> <code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
{request.policy.secretPath} {request.policy.secretPath}
</code> </code>{" "}
in in{" "}
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary"> <code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
{request.environmentName} {request.environmentName}
</code> </code>
</div> </div>
<div>
{request.requestedByUserId === userId && (
<span className="text-xs text-gray-500">
<Badge className="ml-1">Requested By You</Badge>
</span>
)}
</div>
</div> </div>
); );
}; };
@@ -120,30 +120,49 @@ export const AccessApprovalRequest = ({
projectSlug projectSlug
}); });
const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({ const {
data: requests,
refetch: refetchRequests,
isPending: areRequestsPending
} = useGetAccessApprovalRequests({
projectSlug, projectSlug,
authorProjectMembershipId: requestedByFilter, authorProjectMembershipId: requestedByFilter,
envSlug: envFilter envSlug: envFilter
}); });
const [searchFilter, setSearchFilter] = useState("");
const filteredRequests = useMemo(() => { const filteredRequests = useMemo(() => {
let accessRequests: typeof requests;
if (statusFilter === "open") if (statusFilter === "open")
return requests?.filter( accessRequests = requests?.filter(
(request) => (request) =>
!request.policy.deletedAt && !request.policy.deletedAt &&
!request.isApproved && !request.isApproved &&
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
); );
if (statusFilter === "close") if (statusFilter === "close")
return requests?.filter( accessRequests = requests?.filter(
(request) => (request) =>
request.policy.deletedAt || request.policy.deletedAt ||
request.isApproved || request.isApproved ||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
); );
return requests; return accessRequests?.filter((request) => {
}, [requests, statusFilter, requestedByFilter, envFilter]); const { environmentName, requestedByUser } = request;
const searchValue = searchFilter.trim().toLowerCase();
return (
environmentName?.toLowerCase().includes(searchValue) ||
`${requestedByUser?.email ?? ""} ${requestedByUser?.firstName ?? ""} ${requestedByUser?.lastName ?? ""}`
.toLowerCase()
.includes(searchValue)
);
});
}, [requests, statusFilter, requestedByFilter, envFilter, searchFilter]);
const generateRequestDetails = useCallback( const generateRequestDetails = useCallback(
(request: TAccessApprovalRequest) => { (request: TAccessApprovalRequest) => {
@@ -226,46 +245,68 @@ export const AccessApprovalRequest = ({
); );
return ( return (
<div> <AnimatePresence mode="wait">
<div className="mb-6 flex items-end justify-between"> <motion.div
<div className="flex flex-col"> key="approval-changes-list"
<span className="text-xl font-semibold text-mineshaft-100">Access Requests</span> transition={{ duration: 0.1 }}
<div className="mt-2 text-sm text-bunker-300"> initial={{ opacity: 0, translateX: 30 }}
Request access to secrets in sensitive environments and folders. animate={{ opacity: 1, translateX: 0 }}
</div> exit={{ opacity: 0, translateX: 30 }}
</div> className="rounded-md text-gray-300"
<div> >
<Tooltip <div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
content="To submit Access Requests, your project needs to create Access Request policies first." <div className="mb-4 flex items-center justify-between">
isDisabled={policiesLoading || !!policies?.length} <div>
> <div className="flex items-start gap-1">
<Button <p className="text-xl font-semibold text-mineshaft-100">Access Requests</p>
onClick={() => { <a
if (subscription && !subscription?.secretApproval) { href="https://infisical.com/docs/documentation/platform/access-controls/access-requests"
handlePopUpOpen("upgradePlan"); target="_blank"
return; rel="noopener noreferrer"
} >
handlePopUpOpen("requestAccess"); <div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
}} <FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
leftIcon={<FontAwesomeIcon icon={faPlus} />} <span>Docs</span>
isDisabled={policiesLoading || !policies?.length} <FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
Request and review access to secrets in sensitive environments and folders
</p>
</div>
<Tooltip
content="To submit Access Requests, your project needs to create Access Request policies first."
isDisabled={policiesLoading || !!policies?.length}
> >
Request access <Button
</Button> onClick={() => {
</Tooltip> if (subscription && !subscription?.secretApproval) {
</div> handlePopUpOpen("upgradePlan");
</div> return;
}
<AnimatePresence> handlePopUpOpen("requestAccess");
<motion.div }}
key="approval-changes-list" colorSchema="secondary"
transition={{ duration: 0.1 }} leftIcon={<FontAwesomeIcon icon={faPlus} />}
initial={{ opacity: 0, translateX: 30 }} isDisabled={policiesLoading || !policies?.length}
animate={{ opacity: 1, translateX: 0 }} >
exit={{ opacity: 0, translateX: 30 }} Request Access
className="rounded-md text-gray-300" </Button>
> </Tooltip>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8"> </div>
<Input
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search approval requests by requesting user or environment..."
className="flex-1"
containerClassName="mb-4"
/>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 px-8 py-3 text-sm">
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -273,17 +314,19 @@ export const AccessApprovalRequest = ({
onKeyDown={(evt) => { onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open"); if (evt.key === "Enter") setStatusFilter("open");
}} }}
className={ className={twMerge(
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" "font-medium",
} statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
)}
> >
<FontAwesomeIcon icon={faLock} className="mr-2" /> <FontAwesomeIcon icon={faLock} className="mr-2" />
{!!requestCount && requestCount?.pendingCount} Pending {!!requestCount && requestCount?.pendingCount} Pending
</div> </div>
<div <div
className={ className={twMerge(
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : "" "font-medium",
} statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
)}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setStatusFilter("close")} onClick={() => setStatusFilter("close")}
@@ -306,8 +349,14 @@ export const AccessApprovalRequest = ({
Environments Environments
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent
<DropdownMenuLabel>Select an environment</DropdownMenuLabel> align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Environment
</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => ( {currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))} onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
@@ -337,8 +386,14 @@ export const AccessApprovalRequest = ({
Requested By Requested By
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent
<DropdownMenuLabel>Select an author</DropdownMenuLabel> align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select Requesting User
</DropdownMenuLabel>
{members?.map(({ user: membershipUser, id }) => ( {members?.map(({ user: membershipUser, id }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
@@ -359,9 +414,14 @@ export const AccessApprovalRequest = ({
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800"> <div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{filteredRequests?.length === 0 && ( {filteredRequests?.length === 0 && (
<div className="py-12"> <div className="py-12">
<EmptyState title="No more access requests pending." /> <EmptyState
title={`No ${statusFilter === "open" ? "Pending" : "Completed"} Access Requests`}
/>
</div> </div>
)} )}
{Boolean(!filteredRequests?.length && requests?.length && !areRequestsPending) && (
<EmptyState title="No Requests Match Search" icon={faSearch} />
)}
{!!filteredRequests?.length && {!!filteredRequests?.length &&
filteredRequests?.map((request) => { filteredRequests?.map((request) => {
const details = generateRequestDetails(request); const details = generateRequestDetails(request);
@@ -369,7 +429,7 @@ export const AccessApprovalRequest = ({
return ( return (
<div <div
key={request.id} key={request.id}
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80" className="flex w-full cursor-pointer border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700 aria-disabled:opacity-80"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => handleSelectRequest(request)} onClick={() => handleSelectRequest(request)}
@@ -379,14 +439,18 @@ export const AccessApprovalRequest = ({
} }
}} }}
> >
<div className="w-full"> <div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col justify-between"> <div className="flex w-full flex-col justify-between">
<div className="mb-1 flex w-full items-center"> <div className="mb-1 flex w-full items-center">
<FontAwesomeIcon icon={faLock} className="mr-2" /> <FontAwesomeIcon
{generateRequestText(request, user.id)} icon={faLock}
size="xs"
className="mr-1.5 text-mineshaft-300"
/>
{generateRequestText(request)}
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-xs text-gray-500"> <div className="text-xs leading-3 text-gray-500">
{membersGroupById?.[request.requestedByUserId]?.user && ( {membersGroupById?.[request.requestedByUserId]?.user && (
<> <>
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "} Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
@@ -397,61 +461,66 @@ export const AccessApprovalRequest = ({
</> </>
)} )}
</div> </div>
<div>
<Badge variant={details.displayData.type}>
{details.displayData.label}
</Badge>
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
{request.requestedByUserId === user.id && (
<div className="flex items-center gap-1.5 whitespace-nowrap text-xs text-bunker-300">
<FontAwesomeIcon icon={faUser} size="sm" />
<span>Requested By You</span>
</div>
)}
<Badge className="whitespace-nowrap" variant={details.displayData.type}>
{details.displayData.label}
</Badge>
</div>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
</motion.div> </div>
</AnimatePresence> {!!policies && (
<RequestAccessModal
policies={policies}
isOpen={popUp.requestAccess.isOpen}
onOpenChange={() => {
queryClient.invalidateQueries({
queryKey: accessApprovalKeys.getAccessApprovalRequests(
projectSlug,
envFilter,
requestedByFilter
)
});
handlePopUpClose("requestAccess");
}}
/>
)}
{!!policies && ( {!!selectedRequest && (
<RequestAccessModal <ReviewAccessRequestModal
policies={policies} selectedEnvSlug={envFilter}
isOpen={popUp.requestAccess.isOpen} policies={policies || []}
onOpenChange={() => { selectedRequester={requestedByFilter}
queryClient.invalidateQueries({ projectSlug={projectSlug}
queryKey: accessApprovalKeys.getAccessApprovalRequests( request={selectedRequest}
projectSlug, members={members || []}
envFilter, isOpen={popUp.reviewRequest.isOpen}
requestedByFilter onOpenChange={() => {
) handlePopUpClose("reviewRequest");
}); setSelectedRequest(null);
handlePopUpClose("requestAccess"); refetchRequests();
}} }}
canBypass={generateRequestDetails(selectedRequest).canBypass}
/>
)}
<UpgradePlanModal
text="You need to upgrade your plan to access this feature"
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={() => handlePopUpClose("upgradePlan")}
/> />
)} </motion.div>
</AnimatePresence>
{!!selectedRequest && (
<ReviewAccessRequestModal
selectedEnvSlug={envFilter}
policies={policies || []}
selectedRequester={requestedByFilter}
projectSlug={projectSlug}
request={selectedRequest}
members={members || []}
isOpen={popUp.reviewRequest.isOpen}
onOpenChange={() => {
handlePopUpClose("reviewRequest");
setSelectedRequest(null);
refetchRequests();
}}
canBypass={generateRequestDetails(selectedRequest).canBypass}
/>
)}
<UpgradePlanModal
text="You need to upgrade your plan to access this feature"
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={() => handlePopUpClose("upgradePlan")}
/>
</div>
); );
}; };
@@ -1,11 +1,19 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
faArrowDown,
faArrowUp,
faArrowUpRightFromSquare,
faBookOpen,
faCheckCircle, faCheckCircle,
faChevronDown,
faFileShield, faFileShield,
faPlus faFilter,
faMagnifyingGlass,
faPlus,
faSearch
} 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 { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -19,6 +27,9 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
IconButton,
Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -36,7 +47,12 @@ import {
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks"; import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { import {
useDeleteAccessApprovalPolicy, useDeleteAccessApprovalPolicy,
useDeleteSecretApprovalPolicy, useDeleteSecretApprovalPolicy,
@@ -45,6 +61,7 @@ import {
useListWorkspaceGroups useListWorkspaceGroups
} from "@app/hooks/api"; } from "@app/hooks/api";
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PolicyType } from "@app/hooks/api/policies/enums"; import { PolicyType } from "@app/hooks/api/policies/enums";
import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types"; import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
@@ -55,6 +72,18 @@ interface IProps {
workspaceId: string; workspaceId: string;
} }
enum PolicyOrderBy {
Name = "name",
Environment = "environment",
SecretPath = "secret-path",
Type = "type"
}
type PolicyFilters = {
type: null | PolicyType;
environmentIds: string[];
};
const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => { const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => {
const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies( const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies(
{ {
@@ -110,11 +139,79 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
currentWorkspace currentWorkspace
); );
const [filterType, setFilterType] = useState<string | null>(null); const [filters, setFilters] = useState<PolicyFilters>({
type: null,
environmentIds: []
});
const filteredPolicies = useMemo(() => { const {
return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies; search,
}, [policies, filterType]); setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<PolicyOrderBy>(PolicyOrderBy.Name, {
initPerPage: getUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, newPerPage);
};
const filteredPolicies = useMemo(
() =>
policies
.filter(({ policyType, environment, name, secretPath }) => {
if (filters.type && policyType !== filters.type) return false;
if (filters.environmentIds.length && !filters.environmentIds.includes(environment.id))
return false;
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) ||
environment.name.toLowerCase().includes(searchValue) ||
(secretPath ?? "*").toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [policyOne, policyTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case PolicyOrderBy.Type:
return policyOne.policyType
.toLowerCase()
.localeCompare(policyTwo.policyType.toLowerCase());
case PolicyOrderBy.Environment:
return policyOne.environment.name
.toLowerCase()
.localeCompare(policyTwo.environment.name.toLowerCase());
case PolicyOrderBy.SecretPath:
return (policyOne.secretPath ?? "*")
.toLowerCase()
.localeCompare((policyTwo.secretPath ?? "*").toLowerCase());
case PolicyOrderBy.Name:
default:
return policyOne.name.toLowerCase().localeCompare(policyTwo.name.toLowerCase());
}
}),
[policies, filters, search, orderBy, orderDirection]
);
useResetPageHelper({
totalCount: filteredPolicies.length,
offset,
setPage
});
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy(); const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
@@ -149,121 +246,280 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
} }
}; };
const isTableFiltered = filters.type !== null || Boolean(filters.environmentIds.length);
const handleSort = (column: PolicyOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: PolicyOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: PolicyOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return ( return (
<div> <AnimatePresence mode="wait">
<div className="mb-6 flex items-end justify-between"> <motion.div
<div className="flex flex-col"> key="approval-changes-list"
<span className="text-xl font-semibold text-mineshaft-100">Policies</span> transition={{ duration: 0.1 }}
<div className="mt-2 text-sm text-bunker-300"> initial={{ opacity: 0, translateX: 30 }}
Implement granular policies for access requests and secrets management. animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Policies</p>
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
Implement granular policies for access requests and secrets management
</p>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("policyForm");
}}
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Create Policy
</Button>
)}
</ProjectPermissionCan>
</div> </div>
</div> <div className="mb-4 flex items-center gap-2">
<div> <Input
<ProjectPermissionCan value={search}
I={ProjectPermissionActions.Create} onChange={(e) => setSearch(e.target.value)}
a={ProjectPermissionSub.SecretApproval} leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
> placeholder="Search policies by name, type, environment or secret path..."
{(isAllowed) => ( className="flex-1"
<Button />
onClick={() => { <DropdownMenu>
if (subscription && !subscription?.secretApproval) { <DropdownMenuTrigger asChild>
handlePopUpOpen("upgradePlan"); <IconButton
return; ariaLabel="Filter findings"
} variant="plain"
handlePopUpOpen("policyForm"); size="sm"
}} className={twMerge(
leftIcon={<FontAwesomeIcon icon={faPlus} />} "flex h-10 w-11 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",
isDisabled={!isAllowed} isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
align="end"
> >
Create Policy <DropdownMenuLabel>Policy Type</DropdownMenuLabel>
</Button> <DropdownMenuItem
)} onClick={() =>
</ProjectPermissionCan> setFilters((prev) => ({
</div> ...prev,
</div> type: null
<TableContainer> }))
<Table> }
<THead> icon={!filters && <FontAwesomeIcon icon={faCheckCircle} />}
<Tr> iconPos="right"
<Th>Name</Th> >
<Th>Environment</Th> All
<Th>Secret Path</Th> </DropdownMenuItem>
<Th> <DropdownMenuItem
<DropdownMenu> onClick={() =>
<DropdownMenuTrigger> setFilters((prev) => ({
<Button ...prev,
variant="plain" type: PolicyType.AccessPolicy
colorSchema="secondary" }))
className="text-xs font-semibold uppercase text-bunker-300" }
rightIcon={ icon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" /> filters.type === PolicyType.AccessPolicy && (
} <FontAwesomeIcon icon={faCheckCircle} />
> )
}
iconPos="right"
>
Access Policy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setFilters((prev) => ({
...prev,
type: PolicyType.ChangePolicy
}))
}
icon={
filters.type === PolicyType.ChangePolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Change Policy
</DropdownMenuItem>
<DropdownMenuLabel>Environment</DropdownMenuLabel>
{currentWorkspace.environments.map((env) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
environmentIds: prev.environmentIds.includes(env.id)
? prev.environmentIds.filter((i) => i !== env.id)
: [...prev.environmentIds, env.id]
}));
}}
key={env.id}
icon={
filters.environmentIds.includes(env.id) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<span className="capitalize">{env.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Environment
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Environment)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Environment)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Environment)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Secret Path
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.SecretPath)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.SecretPath)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.SecretPath)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Type Type
</Button> <IconButton
</DropdownMenuTrigger> variant="plain"
<DropdownMenuContent> className={getClassName(PolicyOrderBy.Type)}
<DropdownMenuLabel>Select a type</DropdownMenuLabel> ariaLabel="sort"
<DropdownMenuItem onClick={() => handleSort(PolicyOrderBy.Type)}
onClick={() => setFilterType(null)} >
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />} <FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Type)} />
iconPos="right" </IconButton>
> </div>
All </Th>
</DropdownMenuItem> <Th className="w-5" />
<DropdownMenuItem </Tr>
onClick={() => setFilterType(PolicyType.AccessPolicy)} </THead>
icon={ <TBody>
filterType === PolicyType.AccessPolicy && ( {isPoliciesLoading && (
<FontAwesomeIcon icon={faCheckCircle} /> <TableSkeleton
) columns={5}
} innerKey="secret-policies"
iconPos="right" className="bg-mineshaft-700"
> />
Access Policy )}
</DropdownMenuItem> {!isPoliciesLoading && !policies?.length && (
<DropdownMenuItem <Tr>
onClick={() => setFilterType(PolicyType.ChangePolicy)} <Td colSpan={5}>
icon={ <EmptyState title="No Policies Found" icon={faFileShield} />
filterType === PolicyType.ChangePolicy && ( </Td>
<FontAwesomeIcon icon={faCheckCircle} /> </Tr>
) )}
} {!!currentWorkspace &&
iconPos="right" filteredPolicies
> ?.slice(offset, perPage * page)
Change Policy .map((policy) => (
</DropdownMenuItem> <ApprovalPolicyRow
</DropdownMenuContent> policy={policy}
</DropdownMenu> key={policy.id}
</Th> members={members}
<Th /> groups={groups}
</Tr> onEdit={() => handlePopUpOpen("policyForm", policy)}
</THead> onDelete={() => handlePopUpOpen("deletePolicy", policy)}
<TBody> />
{isPoliciesLoading && ( ))}
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" /> </TBody>
</Table>
{Boolean(!filteredPolicies.length && policies.length && !isPoliciesLoading) && (
<EmptyState title="No Policies Match Search" icon={faSearch} />
)} )}
{!isPoliciesLoading && !filteredPolicies?.length && ( {Boolean(filteredPolicies.length) && (
<Tr> <Pagination
<Td colSpan={6}> count={filteredPolicies.length}
<EmptyState title="No policies found" icon={faFileShield} /> page={page}
</Td> perPage={perPage}
</Tr> onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)} )}
{!!currentWorkspace && </TableContainer>
filteredPolicies?.map((policy) => ( </div>
<ApprovalPolicyRow </motion.div>
policy={policy}
key={policy.id}
members={members}
groups={groups}
onEdit={() => handlePopUpOpen("policyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>
))}
</TBody>
</Table>
</TableContainer>
<AccessPolicyForm <AccessPolicyForm
projectId={currentWorkspace.id} projectId={currentWorkspace.id}
projectSlug={currentWorkspace.slug} projectSlug={currentWorkspace.slug}
@@ -284,6 +540,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret approval policy if you switch to Infisical's Enterprise plan." text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
/> />
</div> </AnimatePresence>
); );
}; };
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { RefObject, useMemo, useRef, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -112,21 +112,20 @@ const formSchema = z
type TFormSchema = z.infer<typeof formSchema>; type TFormSchema = z.infer<typeof formSchema>;
export const AccessPolicyForm = ({ const Form = ({
isOpen,
onToggle, onToggle,
members = [], members = [],
projectId, projectId,
projectSlug, projectSlug,
editValues editValues,
}: Props) => { modalContainer,
isEditMode
}: Props & { modalContainer: RefObject<HTMLDivElement>; isEditMode: boolean }) => {
const [draggedItem, setDraggedItem] = useState<number | null>(null); const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null); const [dragOverItem, setDragOverItem] = useState<number | null>(null);
const modalContainer = useRef<HTMLDivElement>(null);
const { const {
control, control,
handleSubmit, handleSubmit,
reset,
watch, watch,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<TFormSchema>({ } = useForm<TFormSchema>({
@@ -191,20 +190,14 @@ export const AccessPolicyForm = ({
const { data: groups } = useListWorkspaceGroups(projectId); const { data: groups } = useListWorkspaceGroups(projectId);
const environments = currentWorkspace?.environments || []; const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy; const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy;
useEffect(() => {
if (!isOpen || !isEditMode) reset({});
}, [isOpen, isEditMode]);
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy(); const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy();
const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy();
const policyName = policyDetails[watch("policyType")]?.name || "Policy";
const enforcementLevel = watch("enforcementLevel"); const enforcementLevel = watch("enforcementLevel");
const formUserBypassers = watch("userBypassers"); const formUserBypassers = watch("userBypassers");
@@ -392,444 +385,452 @@ export const AccessPolicyForm = ({
setDragOverItem(null); setDragOverItem(null);
}; };
return (
<div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="policyType"
defaultValue={PolicyType.ChangePolicy}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Policy Type"
isRequired
isError={Boolean(error)}
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
errorText={error?.message}
className="flex-grow"
>
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val as PolicyType)}
className="w-full border border-mineshaft-500"
>
{Object.values(PolicyType).map((policyType) => {
return (
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
{policyDetails[policyType].name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
{!isAccessPolicyType && (
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Min. Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
)}
</div>
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Policy Name"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mb-2">
<p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
Select members or groups that are allowed to approve requests from this policy.
</p>
</div>
{isAccessPolicyType ? (
<>
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
{sequenceApproversFieldArray.fields.map((el, index) => (
<div
className={twMerge(
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
dragOverItem === index ? "border-2 border-blue-400" : "",
draggedItem === index ? "opacity-50" : ""
)}
key={el.id}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
>
<div className="mb-3 flex items-center justify-between">
<Tag>Step {index + 1}</Tag>
<div className="flex items-center gap-3">
<div className="inline text-xs text-mineshaft-400">Min. Approvals</div>
<div className="mr-2 w-20 border-r border-mineshaft-400 pr-3">
<Controller
control={control}
name={`sequenceApprovers.${index}.approvals` as const}
defaultValue={1}
render={({ field }) => (
<Input
{...field}
type="number"
size="xs"
min={1}
onChange={(val) => field.onChange(parseInt(val.target.value, 10))}
/>
)}
/>
</div>
<Tooltip content="Remove step">
<IconButton
ariaLabel="delete"
variant="plain"
onClick={() => sequenceApproversFieldArray.remove(index)}
className="text-red-500 hover:text-gray-200"
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
<Tooltip content="Drag to reorder permission">
<div
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
className="mr-2 cursor-move text-gray-400 hover:text-gray-200"
>
<FontAwesomeIcon icon={faGripVertical} />
</div>
</Tooltip>
</div>
</div>
<div className="flex gap-2">
<Controller
control={control}
name={`sequenceApprovers.${index}.user` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name={`sequenceApprovers.${index}.group` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
</div>
))}
</div>
<div className="my-2">
<Button
size="xs"
variant="outline_bg"
onClick={() =>
sequenceApproversFieldArray.append({
approvals: 1,
user: [],
group: []
})
}
>
Add Step
</Button>
</div>
</>
) : (
<div className="flex gap-2">
<Controller
control={control}
name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
)}
<Controller
control={control}
name="allowedSelfApprovals"
defaultValue
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl label="Self Approvals" isError={Boolean(error)} errorText={error?.message}>
<Switch
id="self-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Allow approvers to review their own requests
</Switch>
</FormControl>
)}
/>
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Bypass Approvals"
isError={Boolean(error)}
errorText={error?.message}
className="mb-3"
>
<Switch
id="bypass-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value === EnforcementLevel.Soft}
onCheckedChange={(v) => onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard)}
>
Allow certain users to bypass policy in break-glass situations
</Switch>
</FormControl>
)}
/>
{enforcementLevel === EnforcementLevel.Soft && (
<>
<div className="flex gap-2">
<Controller
control={control}
name="userBypassers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Bypassers"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={bypasserMemberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupBypassers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Bypassers"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={bypasserGroupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
{bypasserCount <= 0 && (
<div className="mt-1 flex rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm text-bunker-300">
Not selecting specific users or groups will allow anyone to bypass this policy.
</div>
)}
</>
)}
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
</Button>
<Button onClick={() => onToggle(false)} variant="outline_bg">
Close
</Button>
</div>
</form>
</div>
);
};
export const AccessPolicyForm = ({ isOpen, onToggle, editValues, ...props }: Props) => {
const modalContainer = useRef<HTMLDivElement>(null);
const isEditMode = Boolean(editValues);
return ( return (
<Modal isOpen={isOpen} onOpenChange={onToggle}> <Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent <ModalContent
className="max-w-3xl" className="max-w-3xl"
ref={modalContainer} ref={modalContainer}
title={isEditMode ? `Edit ${policyName}` : "Create Policy"} title={isEditMode ? "Edit Policy" : "Create Policy"}
> >
<div className="flex flex-col space-y-3"> <Form
<form onSubmit={handleSubmit(handleFormSubmit)}> {...props}
<div className="flex items-center gap-x-3"> isOpen={isOpen}
<Controller onToggle={onToggle}
control={control} editValues={editValues}
name="policyType" modalContainer={modalContainer}
defaultValue={PolicyType.ChangePolicy} isEditMode={isEditMode}
render={({ field: { value, onChange }, fieldState: { error } }) => ( />
<FormControl
label="Policy Type"
isRequired
isError={Boolean(error)}
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
errorText={error?.message}
className="flex-grow"
>
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val as PolicyType)}
className="w-full border border-mineshaft-500"
>
{Object.values(PolicyType).map((policyType) => {
return (
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
{policyDetails[policyType].name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
{!isAccessPolicyType && (
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Min. Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
)}
</div>
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Policy Name"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mb-2">
<p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
Select members or groups that are allowed to approve requests from this policy.
</p>
</div>
{isAccessPolicyType ? (
<>
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
{sequenceApproversFieldArray.fields.map((el, index) => (
<div
className={twMerge(
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
dragOverItem === index ? "border-2 border-blue-400" : "",
draggedItem === index ? "opacity-50" : ""
)}
key={el.id}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
>
<div className="mb-3 flex items-center justify-between">
<Tag>Step {index + 1}</Tag>
<div className="flex items-center gap-3">
<div className="inline text-xs text-mineshaft-400">Min. Approvals</div>
<div className="mr-2 w-20 border-r border-mineshaft-400 pr-3">
<Controller
control={control}
name={`sequenceApprovers.${index}.approvals` as const}
defaultValue={1}
render={({ field }) => (
<Input
{...field}
type="number"
size="xs"
min={1}
onChange={(val) => field.onChange(parseInt(val.target.value, 10))}
/>
)}
/>
</div>
<Tooltip content="Remove step">
<IconButton
ariaLabel="delete"
variant="plain"
onClick={() => sequenceApproversFieldArray.remove(index)}
className="text-red-500 hover:text-gray-200"
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
<Tooltip content="Drag to reorder permission">
<div
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
className="mr-2 cursor-move text-gray-400 hover:text-gray-200"
>
<FontAwesomeIcon icon={faGripVertical} />
</div>
</Tooltip>
</div>
</div>
<div className="flex gap-2">
<Controller
control={control}
name={`sequenceApprovers.${index}.user` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name={`sequenceApprovers.${index}.group` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
</div>
))}
</div>
<div className="my-2">
<Button
size="xs"
variant="outline_bg"
onClick={() =>
sequenceApproversFieldArray.append({
approvals: 1,
user: [],
group: []
})
}
>
Add Step
</Button>
</div>
</>
) : (
<div className="flex gap-2">
<Controller
control={control}
name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
)}
<Controller
control={control}
name="allowedSelfApprovals"
defaultValue
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Self Approvals"
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
id="self-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Allow approvers to review their own requests
</Switch>
</FormControl>
)}
/>
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Bypass Approvals"
isError={Boolean(error)}
errorText={error?.message}
className="mb-3"
>
<Switch
id="bypass-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value === EnforcementLevel.Soft}
onCheckedChange={(v) =>
onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard)
}
>
Allow certain users to bypass policy in break-glass situations
</Switch>
</FormControl>
)}
/>
{enforcementLevel === EnforcementLevel.Soft && (
<>
<div className="flex gap-2">
<Controller
control={control}
name="userBypassers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Bypassers"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={bypasserMemberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupBypassers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Bypassers"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={bypasserGroupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
{bypasserCount <= 0 && (
<div className="mt-1 flex rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm text-bunker-300">
Not selecting specific users or groups will allow anyone to bypass this policy.
</div>
)}
</>
)}
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
</Button>
<Button onClick={() => onToggle(false)} variant="outline_bg">
Close
</Button>
</div>
</form>
</div>
</ModalContent> </ModalContent>
</Modal> </Modal>
); );
@@ -1,7 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; import { faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
@@ -9,6 +8,8 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
GenericFieldLabel,
IconButton,
Td, Td,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
@@ -102,8 +103,8 @@ export const ApprovalPolicyRow = ({
}} }}
onClick={() => setIsExpanded.toggle()} onClick={() => setIsExpanded.toggle()}
> >
<Td>{policy.name}</Td> <Td>{policy.name || <span className="text-mineshaft-400">Unnamed Policy</span>}</Td>
<Td>{policy.environment.slug}</Td> <Td>{policy.environment.name}</Td>
<Td>{policy.secretPath || "*"}</Td> <Td>{policy.secretPath || "*"}</Td>
<Td> <Td>
<Badge className={policyDetails[policy.policyType].className}> <Badge className={policyDetails[policy.policyType].className}>
@@ -113,25 +114,30 @@ export const ApprovalPolicyRow = ({
<Td> <Td>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg"> <DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400"> <DropdownMenuTrigger asChild>
<FontAwesomeIcon size="sm" icon={faEllipsis} /> <IconButton
</div> ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100%] p-1"> <DropdownMenuContent sideOffset={2} align="end" className="min-w-[12rem] p-1">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}
> >
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuItem <DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEdit(); onEdit();
}} }}
disabled={!isAllowed} isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
> >
Edit Policy Edit Policy
</DropdownMenuItem> </DropdownMenuItem>
@@ -143,16 +149,12 @@ export const ApprovalPolicyRow = ({
> >
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuItem <DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete(); onDelete();
}} }}
disabled={!isAllowed} isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
> >
Delete Policy Delete Policy
</DropdownMenuItem> </DropdownMenuItem>
@@ -162,45 +164,41 @@ export const ApprovalPolicyRow = ({
</DropdownMenu> </DropdownMenu>
</Td> </Td>
</Tr> </Tr>
{isExpanded && ( <Tr>
<Tr> <Td colSpan={6} className="!border-none p-0">
<Td colSpan={5} className="rounded bg-mineshaft-900"> <div
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div> className={`w-full overflow-hidden bg-mineshaft-900/75 transition-all duration-500 ease-in-out ${
{labels?.map((el, index) => ( isExpanded ? "thin-scrollbar max-h-[26rem] !overflow-y-auto opacity-100" : "max-h-0"
<div }`}
key={`approval-list-${index + 1}`} >
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4" <div className="p-4">
> <div className="mb-4 border-b-2 border-mineshaft-500 pb-2">Approvers</div>
<div> {labels?.map((el, index) => (
<div className="mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white"> <div
<div className="text-lg">{index + 1}</div> key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-800 p-4"
>
<div className="my-auto mr-8 flex h-8 w-8 items-center justify-center rounded border border-mineshaft-400 bg-bunker-500/50 text-white">
<div>{index + 1}</div>
</div> </div>
{index !== labels.length - 1 && ( {index !== labels.length - 1 && (
<div className="absolute bottom-0 left-8 h-6 border-r border-gray-400" /> <div className="absolute bottom-0 left-8 h-[1.25rem] border-r border-mineshaft-400" />
)} )}
{index !== 0 && ( {index !== 0 && (
<div className="absolute left-8 top-0 h-4 border-r border-gray-400" /> <div className="absolute left-8 top-0 h-[1.25rem] border-r border-mineshaft-400" />
)} )}
</div>
<div className="grid flex-grow grid-cols-3"> <div className="grid flex-grow grid-cols-3">
<div> <GenericFieldLabel label="Users">{el.userLabels}</GenericFieldLabel>
<div className="mb-1 text-xs font-semibold uppercase">Users</div> <GenericFieldLabel label="Groups">{el.groupLabels}</GenericFieldLabel>
<div>{el.userLabels || "-"}</div> <GenericFieldLabel label="Approvals Required">{el.approvals}</GenericFieldLabel>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>{el.groupLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{el.approvals || "-"}</div>
</div> </div>
</div> </div>
</div> ))}
))} </div>
</Td> </div>
</Tr> </Td>
)} </Tr>
</> </>
); );
}; };
@@ -1,14 +1,19 @@
import { Fragment, useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
faArrowUpRightFromSquare,
faBookOpen,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
faChevronDown, faChevronDown,
faCodeBranch faCodeBranch,
faMagnifyingGlass,
faSearch
} 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 { useSearch } from "@tanstack/react-router"; import { useSearch } from "@tanstack/react-router";
import { formatDistance } from "date-fns"; import { formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { import {
Button, Button,
@@ -18,6 +23,7 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
Input,
Skeleton Skeleton
} from "@app/components/v2"; } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes"; import { ROUTE_PATHS } from "@app/const/routes";
@@ -91,6 +97,28 @@ export const SecretApprovalRequest = () => {
const isRequestListEmpty = const isRequestListEmpty =
!isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0; !isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0;
const [searchFilter, setSearchFilter] = useState("");
const filteredRequests = useMemo(
() =>
secretApprovalRequests?.pages.flatMap((requests) =>
requests.filter((request) => {
const { environment, committerUser, secretPath } = request;
const searchValue = searchFilter.trim().toLowerCase();
return (
environment?.toLowerCase().includes(searchValue) ||
`${committerUser?.email ?? ""} ${committerUser?.firstName ?? ""} ${committerUser?.lastName ?? ""}`
.toLowerCase()
.includes(searchValue) ||
secretPath?.toLowerCase().includes(searchValue)
);
})
) ?? [],
[secretApprovalRequests?.pages, searchFilter]
);
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{isSecretApprovalScreen ? ( {isSecretApprovalScreen ? (
@@ -116,178 +144,231 @@ export const SecretApprovalRequest = () => {
exit={{ opacity: 0, translateX: 30 }} exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300" className="rounded-md text-gray-300"
> >
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8"> <div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div <div className="mb-4 flex items-center justify-between">
role="button" <div>
tabIndex={0} <div className="flex items-start gap-1">
onClick={() => setStatusFilter("open")} <p className="text-xl font-semibold text-mineshaft-100">Change Requests</p>
onKeyDown={(evt) => { <a
if (evt.key === "Enter") setStatusFilter("open"); href="https://infisical.com/docs/documentation/platform/pr-workflows"
}} target="_blank"
className={ rel="noopener noreferrer"
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
>
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
</div>
<div
className={
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
role="button"
tabIndex={0}
onClick={() => setStatusFilter("close")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("close");
}}
>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
</div>
<div className="flex flex-grow justify-end space-x-8">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="plain"
colorSchema="secondary"
className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
> >
Environments <div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
</Button> <FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
</DropdownMenuTrigger> <span>Docs</span>
<DropdownMenuContent> <FontAwesomeIcon
<DropdownMenuLabel>Select an environment</DropdownMenuLabel> icon={faArrowUpRightFromSquare}
{currentWorkspace?.environments.map(({ slug, name }) => ( className="mb-[0.07rem] ml-1.5 text-[10px]"
<DropdownMenuItem />
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))} </div>
key={`request-filter-${slug}`} </a>
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />} </div>
iconPos="right" <p className="text-sm text-bunker-300">Review pending and closed change requests</p>
> </div>
{name} </div>
</DropdownMenuItem> <Input
))} value={searchFilter}
</DropdownMenuContent> onChange={(e) => setSearchFilter(e.target.value)}
</DropdownMenu> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
{!!permission.can( placeholder="Search change requests by author, environment slug or secret path..."
ProjectPermissionMemberActions.Read, className="flex-1"
ProjectPermissionSub.Member containerClassName="mb-4"
) && ( />
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 px-8 py-3 text-sm">
<div
role="button"
tabIndex={0}
onClick={() => setStatusFilter("open")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open");
}}
className={twMerge(
"font-medium",
statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
)}
>
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
</div>
<div
className={twMerge(
"font-medium",
statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
)}
role="button"
tabIndex={0}
onClick={() => setStatusFilter("close")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("close");
}}
>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
</div>
<div className="flex flex-grow justify-end space-x-8">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger asChild>
<Button <Button
variant="plain" variant="plain"
colorSchema="secondary" colorSchema="secondary"
className={committerFilter ? "text-white" : "text-bunker-300"} className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={ rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" /> <FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
} }
> >
Author Environments
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent
<DropdownMenuLabel>Select an author</DropdownMenuLabel> align="end"
{members?.map(({ user, id }) => ( sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Environment
</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
setCommitterFilter((state) => (state === user.id ? undefined : user.id)) key={`request-filter-${slug}`}
} icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
key={`request-filter-member-${id}`}
icon={
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right" iconPos="right"
> >
{user.username} {name}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className={committerFilter ? "text-white" : "text-bunker-300"}
rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
>
Author
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Author
</DropdownMenuLabel>
{members?.map(({ user, id }) => (
<DropdownMenuItem
onClick={() =>
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
}
key={`request-filter-member-${id}`}
icon={
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right"
>
{user.username}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{isRequestListEmpty && (
<div className="py-12">
<EmptyState
title={`No ${statusFilter === "open" ? "Open" : "Closed"} Change Requests`}
/>
</div>
)}
{filteredRequests.map((secretApproval) => {
const {
id: reqId,
commits,
createdAt,
reviewers,
status,
committerUser
} = secretApproval;
const isReviewed = reviewers.some(
({ status: reviewStatus, userId }) =>
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
);
return (
<div
key={reqId}
className="flex flex-col border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onClick={() => setSelectedApprovalId(secretApproval.id)}
onKeyDown={(evt) => {
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
}}
>
<div className="mb-1">
<FontAwesomeIcon
icon={faCodeBranch}
size="sm"
className="mr-1.5 text-mineshaft-300"
/>
{secretApproval.isReplicated
? `${commits.length} secret pending import`
: generateCommitText(commits)}
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
</div>
<span className="text-xs leading-3 text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
{committerUser?.email})
{!isReviewed && status === "open" && " - Review required"}
</span>
</div>
);
})}
{Boolean(
!filteredRequests.length && !isRequestListEmpty && !isApprovalRequestLoading
) && <EmptyState title="No Requests Match Search" icon={faSearch} />}
{(isFetchingNextApprovalRequest || isApprovalRequestLoading) && (
<div>
{Array.apply(0, Array(3)).map((_x, index) => (
<div
key={`approval-request-loading-${index + 1}`}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
>
<div className="mb-2 flex items-center">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
<Skeleton className="w-1/4 bg-mineshaft-600" />
</div>
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
))}
</div>
)} )}
</div> </div>
</div> {hasNextApprovalPage && (
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800"> <Button
{isRequestListEmpty && ( className="mt-4 text-sm"
<div className="py-12"> isFullWidth
<EmptyState title="No more requests pending." /> colorSchema="secondary"
</div> isLoading={isFetchingNextApprovalRequest}
)} isDisabled={isFetchingNextApprovalRequest || !hasNextApprovalPage}
{secretApprovalRequests?.pages?.map((group, i) => ( onClick={() => fetchNextApprovalRequest()}
<Fragment key={`secret-approval-request-${i + 1}`}> >
{group?.map((secretApproval) => { {hasNextApprovalPage ? "Load More" : "End of History"}
const { </Button>
id: reqId,
commits,
createdAt,
reviewers,
status,
committerUser
} = secretApproval;
const isReviewed = reviewers.some(
({ status: reviewStatus, userId }) =>
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
);
return (
<div
key={reqId}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onClick={() => setSelectedApprovalId(secretApproval.id)}
onKeyDown={(evt) => {
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
}}
>
<div className="mb-1">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{secretApproval.isReplicated
? `${commits.length} secret pending import`
: generateCommitText(commits)}
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
</div>
<span className="text-xs text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
{committerUser?.email})
{!isReviewed && status === "open" && " - Review required"}
</span>
</div>
);
})}
</Fragment>
))}
{(isFetchingNextApprovalRequest || isApprovalRequestLoading) && (
<div>
{Array.apply(0, Array(3)).map((_x, index) => (
<div
key={`approval-request-loading-${index + 1}`}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
>
<div className="mb-2 flex items-center">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
<Skeleton className="w-1/4 bg-mineshaft-600" />
</div>
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
))}
</div>
)} )}
</div> </div>
{hasNextApprovalPage && (
<Button
className="mt-4 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextApprovalRequest}
isDisabled={isFetchingNextApprovalRequest || !hasNextApprovalPage}
onClick={() => fetchNextApprovalRequest()}
>
{hasNextApprovalPage ? "Load More" : "End of history"}
</Button>
)}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -56,27 +56,24 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
if (score[CommitType.CREATE]) if (score[CommitType.CREATE])
text.push( text.push(
<span key="created-commit"> <span key="created-commit">
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"} {score[CommitType.CREATE]} Secret{score[CommitType.CREATE] !== 1 && "s"}
<span style={{ color: "#60DD00" }}> created</span> <span className="text-green-600"> Created</span>
</span> </span>
); );
if (score[CommitType.UPDATE]) if (score[CommitType.UPDATE])
text.push( text.push(
<span key="updated-commit"> <span key="updated-commit">
{Boolean(text.length) && ","} {Boolean(text.length) && ", "}
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"} {score[CommitType.UPDATE]} Secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#F8EB30" }} className="text-orange-600"> <span className="text-yellow-600"> Updated</span>
{" "}
updated
</span>
</span> </span>
); );
if (score[CommitType.DELETE]) if (score[CommitType.DELETE])
text.push( text.push(
<span className="deleted-commit"> <span className="deleted-commit">
{Boolean(text.length) && "and"} {Boolean(text.length) && "and"}
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"} {score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"}
<span style={{ color: "#F83030" }}> deleted</span> <span className="text-red-600"> Deleted</span>
</span> </span>
); );
return text; return text;