diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index ce745245f..846dc2176 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -60,6 +60,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv committerUser: approvalRequestUser, commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), environment: z.string(), + secretPath: z.string(), reviewers: z.object({ userId: z.string(), status: z.string() }).array(), approvers: z .object({ diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index e70d0af00..1e000c05c 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -208,8 +208,21 @@ export const secretApprovalRequestServiceFactory = ({ }); const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + const getSecretMapPath = async (folderIds: string[]) => { + const secretPaths = await folderDAL.findSecretPathByFolderIds(projectId, folderIds); + + const secretPathMap: Record = {}; + + secretPaths.forEach((folder) => { + if (folder) secretPathMap[folder.id] = folder.path; + }); + + return secretPathMap; + }; + if (shouldUseSecretV2Bridge) { - return secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({ + const approvalsV2 = await secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({ projectId, committer, environment, @@ -218,7 +231,12 @@ export const secretApprovalRequestServiceFactory = ({ limit, 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({ projectId, committer, @@ -228,7 +246,10 @@ export const secretApprovalRequestServiceFactory = ({ limit, 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 ({ diff --git a/frontend/src/components/v2/Dropdown/Dropdown.tsx b/frontend/src/components/v2/Dropdown/Dropdown.tsx index c4cc95429..b1831187a 100644 --- a/frontend/src/components/v2/Dropdown/Dropdown.tsx +++ b/frontend/src/components/v2/Dropdown/Dropdown.tsx @@ -94,7 +94,7 @@ export const DropdownMenuItem = ({ 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", className, - isDisabled ? "pointer-events-none opacity-50" : "" + isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : "" )} > diff --git a/frontend/src/helpers/policies.ts b/frontend/src/helpers/policies.ts index 7828807dc..e1a522e9f 100644 --- a/frontend/src/helpers/policies.ts +++ b/frontend/src/helpers/policies.ts @@ -2,11 +2,11 @@ import { PolicyType } from "@app/hooks/api/policies/enums"; export const policyDetails: Record = { [PolicyType.AccessPolicy]: { - className: "bg-lime-900 text-lime-100", + className: "bg-yellow-500/40 text-mineshaft-100", name: "Access Policy" }, [PolicyType.ChangePolicy]: { - className: "bg-indigo-900 text-indigo-100", + className: "bg-blue-500/40 text-mineshaft-100", name: "Change Policy" } }; diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index 3711b7632..86fc39300 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -352,9 +352,9 @@ export const ProjectLayout = () => { secretApprovalReqCount?.open || accessApprovalRequestCount?.pendingCount ) && ( - + {pendingRequestsCount} - + )} )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx index a70a6a901..bc82e9b90 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/SecretApprovalsPage.tsx @@ -1,7 +1,5 @@ import { Helmet } from "react-helmet"; 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 { Badge } from "@app/components/v2/Badge"; @@ -45,21 +43,7 @@ export const SecretApprovalsPage = () => { - - - Documentation - - - - + /> diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 86483e2fd..a3fb8aeb4 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -2,15 +2,21 @@ /* eslint-disable react/jsx-no-useless-fragment */ import { useCallback, useMemo, useState } from "react"; import { + faArrowUpRightFromSquare, + faBookOpen, faCheck, faCheckCircle, faChevronDown, faLock, - faPlus + faMagnifyingGlass, + faPlus, + faSearch, + faUser } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { formatDistance } from "date-fns"; import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { @@ -21,6 +27,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, EmptyState, + Input, Tooltip } from "@app/components/v2"; 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 { ReviewAccessRequestModal } from "./components/ReviewAccessModal"; -const generateRequestText = (request: TAccessApprovalRequest, userId: string) => { +const generateRequestText = (request: TAccessApprovalRequest) => { const { isTemporary } = request; return ( -
+
Requested {isTemporary ? "temporary" : "permanent"} access to{" "} - + {request.policy.secretPath} - - in - + {" "} + in{" "} + {request.environmentName}
-
- {request.requestedByUserId === userId && ( - - Requested By You - - )} -
); }; @@ -120,30 +120,49 @@ export const AccessApprovalRequest = ({ projectSlug }); - const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({ + const { + data: requests, + refetch: refetchRequests, + isPending: areRequestsPending + } = useGetAccessApprovalRequests({ projectSlug, authorProjectMembershipId: requestedByFilter, envSlug: envFilter }); + const [searchFilter, setSearchFilter] = useState(""); + const filteredRequests = useMemo(() => { + let accessRequests: typeof requests; + if (statusFilter === "open") - return requests?.filter( + accessRequests = requests?.filter( (request) => !request.policy.deletedAt && !request.isApproved && !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) ); if (statusFilter === "close") - return requests?.filter( + accessRequests = requests?.filter( (request) => request.policy.deletedAt || request.isApproved || request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) ); - return requests; - }, [requests, statusFilter, requestedByFilter, envFilter]); + return accessRequests?.filter((request) => { + 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( (request: TAccessApprovalRequest) => { @@ -226,46 +245,68 @@ export const AccessApprovalRequest = ({ ); return ( -
-
-
- Access Requests -
- Request access to secrets in sensitive environments and folders. -
-
-
- - - -
-
- - - -
+ + +
+ setSearchFilter(e.target.value)} + leftIcon={} + placeholder="Search approval requests by requesting user or environment..." + className="flex-1" + containerClassName="mb-4" + /> +
{ if (evt.key === "Enter") setStatusFilter("open"); }} - className={ - statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" - } + className={twMerge( + "font-medium", + statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400" + )} > {!!requestCount && requestCount?.pendingCount} Pending
setStatusFilter("close")} @@ -306,8 +349,14 @@ export const AccessApprovalRequest = ({ Environments - - Select an environment + + + Select an Environment + {currentWorkspace?.environments.map(({ slug, name }) => ( setEnvFilter((state) => (state === slug ? undefined : slug))} @@ -337,8 +386,14 @@ export const AccessApprovalRequest = ({ Requested By - - Select an author + + + Select Requesting User + {members?.map(({ user: membershipUser, id }) => ( @@ -359,9 +414,14 @@ export const AccessApprovalRequest = ({
{filteredRequests?.length === 0 && (
- +
)} + {Boolean(!filteredRequests?.length && requests?.length && !areRequestsPending) && ( + + )} {!!filteredRequests?.length && filteredRequests?.map((request) => { const details = generateRequestDetails(request); @@ -369,7 +429,7 @@ export const AccessApprovalRequest = ({ return (
handleSelectRequest(request)} @@ -379,14 +439,18 @@ export const AccessApprovalRequest = ({ } }} > -
+
- - {generateRequestText(request, user.id)} + + {generateRequestText(request)}
-
+
{membersGroupById?.[request.requestedByUserId]?.user && ( <> Requested {formatDistance(new Date(request.createdAt), new Date())}{" "} @@ -397,61 +461,66 @@ export const AccessApprovalRequest = ({ )}
-
- - {details.displayData.label} - -
+
+ {request.requestedByUserId === user.id && ( +
+ + Requested By You +
+ )} + + {details.displayData.label} + +
); })}
- - +
+ {!!policies && ( + { + queryClient.invalidateQueries({ + queryKey: accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envFilter, + requestedByFilter + ) + }); + handlePopUpClose("requestAccess"); + }} + /> + )} - {!!policies && ( - { - queryClient.invalidateQueries({ - queryKey: accessApprovalKeys.getAccessApprovalRequests( - projectSlug, - envFilter, - requestedByFilter - ) - }); - handlePopUpClose("requestAccess"); - }} + {!!selectedRequest && ( + { + handlePopUpClose("reviewRequest"); + setSelectedRequest(null); + refetchRequests(); + }} + canBypass={generateRequestDetails(selectedRequest).canBypass} + /> + )} + + handlePopUpClose("upgradePlan")} /> - )} - - {!!selectedRequest && ( - { - handlePopUpClose("reviewRequest"); - setSelectedRequest(null); - refetchRequests(); - }} - canBypass={generateRequestDetails(selectedRequest).canBypass} - /> - )} - - handlePopUpClose("upgradePlan")} - /> -
+ + ); }; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index 3d1ba70ef..e2ef64d3e 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -1,11 +1,19 @@ import { useMemo, useState } from "react"; import { + faArrowDown, + faArrowUp, + faArrowUpRightFromSquare, + faBookOpen, faCheckCircle, - faChevronDown, faFileShield, - faPlus + faFilter, + faMagnifyingGlass, + faPlus, + faSearch } from "@fortawesome/free-solid-svg-icons"; 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 { createNotification } from "@app/components/notifications"; @@ -19,6 +27,9 @@ import { DropdownMenuLabel, DropdownMenuTrigger, EmptyState, + IconButton, + Input, + Pagination, Table, TableContainer, TableSkeleton, @@ -36,7 +47,12 @@ import { useWorkspace } from "@app/context"; 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 { useDeleteAccessApprovalPolicy, useDeleteSecretApprovalPolicy, @@ -45,6 +61,7 @@ import { useListWorkspaceGroups } from "@app/hooks/api"; 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 { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types"; @@ -55,6 +72,18 @@ interface IProps { 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 { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies( { @@ -110,11 +139,79 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { currentWorkspace ); - const [filterType, setFilterType] = useState(null); + const [filters, setFilters] = useState({ + type: null, + environmentIds: [] + }); - const filteredPolicies = useMemo(() => { - return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies; - }, [policies, filterType]); + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + orderBy, + setOrderBy, + setOrderDirection, + toggleOrderDirection + } = usePagination(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: 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 ( -
-
-
- Policies -
- Implement granular policies for access requests and secrets management. + + +
+
+
+
+

Policies

+ +
+ + Docs + +
+
+
+

+ Implement granular policies for access requests and secrets management +

+
+ + {(isAllowed) => ( + + )} +
-
-
- - {(isAllowed) => ( - - )} - -
-
- - - - - - - - - - + {Boolean(filteredPolicies.length) && ( + )} - {!!currentWorkspace && - filteredPolicies?.map((policy) => ( - handlePopUpOpen("policyForm", policy)} - onDelete={() => handlePopUpOpen("deletePolicy", policy)} - /> - ))} - -
NameEnvironmentSecret Path - - -
- -
-
+ +
+ { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="You can add secret approval policy if you switch to Infisical's Enterprise plan." /> -
+ ); }; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index 61fdeeb04..a20d95db9 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -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 { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -112,21 +112,20 @@ const formSchema = z type TFormSchema = z.infer; -export const AccessPolicyForm = ({ - isOpen, +const Form = ({ onToggle, members = [], projectId, projectSlug, - editValues -}: Props) => { + editValues, + modalContainer, + isEditMode +}: Props & { modalContainer: RefObject; isEditMode: boolean }) => { const [draggedItem, setDraggedItem] = useState(null); const [dragOverItem, setDragOverItem] = useState(null); - const modalContainer = useRef(null); const { control, handleSubmit, - reset, watch, formState: { isSubmitting } } = useForm({ @@ -191,20 +190,14 @@ export const AccessPolicyForm = ({ const { data: groups } = useListWorkspaceGroups(projectId); const environments = currentWorkspace?.environments || []; - const isEditMode = Boolean(editValues); const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy; - useEffect(() => { - if (!isOpen || !isEditMode) reset({}); - }, [isOpen, isEditMode]); - const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy(); const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); - const policyName = policyDetails[watch("policyType")]?.name || "Policy"; const enforcementLevel = watch("enforcementLevel"); const formUserBypassers = watch("userBypassers"); @@ -392,444 +385,452 @@ export const AccessPolicyForm = ({ setDragOverItem(null); }; + return ( +
+
+
+ ( + + + + )} + /> + {!isAccessPolicyType && ( + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> + )} +
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
+

Approvers

+

+ Select members or groups that are allowed to approve requests from this policy. +

+
+ {isAccessPolicyType ? ( + <> +
+ {sequenceApproversFieldArray.fields.map((el, index) => ( +
handleDragOver(e, index)} + onDrop={handleDrop} + > +
+ Step {index + 1} +
+
Min. Approvals
+
+ ( + field.onChange(parseInt(val.target.value, 10))} + /> + )} + /> +
+ + sequenceApproversFieldArray.remove(index)} + className="text-red-500 hover:text-gray-200" + > + + + + +
handleDragStart(e, index)} + onDragEnd={handleDragEnd} + className="mr-2 cursor-move text-gray-400 hover:text-gray-200" + > + +
+
+
+
+
+ ( + + 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} + /> + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? + option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
+
+ ))} +
+
+ +
+ + ) : ( +
+ ( + + 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} + /> + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
+ )} + ( + + + Allow approvers to review their own requests + + + )} + /> + ( + + onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard)} + > + Allow certain users to bypass policy in break-glass situations + + + )} + /> + {enforcementLevel === EnforcementLevel.Soft && ( + <> +
+ ( + + 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} + /> + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
+ + {bypasserCount <= 0 && ( +
+ Not selecting specific users or groups will allow anyone to bypass this policy. +
+ )} + + )} +
+ + +
+ +
+ ); +}; + +export const AccessPolicyForm = ({ isOpen, onToggle, editValues, ...props }: Props) => { + const modalContainer = useRef(null); + const isEditMode = Boolean(editValues); + return ( -
-
-
- ( - - - - )} - /> - {!isAccessPolicyType && ( - ( - - field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> - )} -
-
- ( - - - - )} - /> - ( - - - - )} - /> -
- ( - - option.slug} - getOptionLabel={(option) => option.name} - /> - - )} - /> -
-

Approvers

-

- Select members or groups that are allowed to approve requests from this policy. -

-
- {isAccessPolicyType ? ( - <> -
- {sequenceApproversFieldArray.fields.map((el, index) => ( -
handleDragOver(e, index)} - onDrop={handleDrop} - > -
- Step {index + 1} -
-
Min. Approvals
-
- ( - field.onChange(parseInt(val.target.value, 10))} - /> - )} - /> -
- - sequenceApproversFieldArray.remove(index)} - className="text-red-500 hover:text-gray-200" - > - - - - -
handleDragStart(e, index)} - onDragEnd={handleDragEnd} - className="mr-2 cursor-move text-gray-400 hover:text-gray-200" - > - -
-
-
-
-
- ( - - 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} - /> - - )} - /> - ( - - option.id} - getOptionLabel={(option) => - groups?.find(({ group }) => group.id === option.id)?.group.name ?? - option.id - } - value={value} - onChange={onChange} - /> - - )} - /> -
-
- ))} -
-
- -
- - ) : ( -
- ( - - 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} - /> - - )} - /> - ( - - option.id} - getOptionLabel={(option) => - groups?.find(({ group }) => group.id === option.id)?.group.name ?? - option.id - } - value={value} - onChange={onChange} - /> - - )} - /> -
- )} - ( - - - Allow approvers to review their own requests - - - )} - /> - ( - - - onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard) - } - > - Allow certain users to bypass policy in break-glass situations - - - )} - /> - {enforcementLevel === EnforcementLevel.Soft && ( - <> -
- ( - - 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} - /> - - )} - /> - ( - - option.id} - getOptionLabel={(option) => - groups?.find(({ group }) => group.id === option.id)?.group.name ?? - option.id - } - value={value} - onChange={onChange} - /> - - )} - /> -
- - {bypasserCount <= 0 && ( -
- Not selecting specific users or groups will allow anyone to bypass this policy. -
- )} - - )} -
- - -
- -
+
); diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx index 9b14af5ba..375e86aa6 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -1,7 +1,6 @@ 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 { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { @@ -9,6 +8,8 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + GenericFieldLabel, + IconButton, Td, Tr } from "@app/components/v2"; @@ -102,8 +103,8 @@ export const ApprovalPolicyRow = ({ }} onClick={() => setIsExpanded.toggle()} > - {policy.name} - {policy.environment.slug} + {policy.name || Unnamed Policy} + {policy.environment.name} {policy.secretPath || "*"} @@ -113,25 +114,30 @@ export const ApprovalPolicyRow = ({ -
- -
+ + + + +
- + {(isAllowed) => ( { e.stopPropagation(); onEdit(); }} - disabled={!isAllowed} + isDisabled={!isAllowed} + icon={} > Edit Policy @@ -143,16 +149,12 @@ export const ApprovalPolicyRow = ({ > {(isAllowed) => ( { e.stopPropagation(); onDelete(); }} - disabled={!isAllowed} + isDisabled={!isAllowed} + icon={} > Delete Policy @@ -162,45 +164,41 @@ export const ApprovalPolicyRow = ({
- {isExpanded && ( - - -
Approvers
- {labels?.map((el, index) => ( -
-
-
-
{index + 1}
+ + +
+
+
Approvers
+ {labels?.map((el, index) => ( +
+
+
{index + 1}
{index !== labels.length - 1 && ( -
+
)} {index !== 0 && ( -
+
)} -
-
-
-
Users
-
{el.userLabels || "-"}
-
-
-
Groups
-
{el.groupLabels || "-"}
-
-
-
Approvals Required
-
{el.approvals || "-"}
+ +
+ {el.userLabels} + {el.groupLabels} + {el.approvals}
-
- ))} - - - )} + ))} +
+
+ + ); }; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index ca0e598a2..fc602d6f9 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -1,14 +1,19 @@ -import { Fragment, useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { + faArrowUpRightFromSquare, + faBookOpen, faCheck, faCheckCircle, faChevronDown, - faCodeBranch + faCodeBranch, + faMagnifyingGlass, + faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useSearch } from "@tanstack/react-router"; import { formatDistance } from "date-fns"; import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { Button, @@ -18,6 +23,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, EmptyState, + Input, Skeleton } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; @@ -91,6 +97,28 @@ export const SecretApprovalRequest = () => { const isRequestListEmpty = !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 ( {isSecretApprovalScreen ? ( @@ -116,178 +144,231 @@ export const SecretApprovalRequest = () => { exit={{ opacity: 0, translateX: 30 }} className="rounded-md text-gray-300" > -
-
setStatusFilter("open")} - onKeyDown={(evt) => { - if (evt.key === "Enter") setStatusFilter("open"); - }} - className={ - statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" - } - > - - {isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open -
-
setStatusFilter("close")} - onKeyDown={(evt) => { - if (evt.key === "Enter") setStatusFilter("close"); - }} - > - - {isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed -
- +

Review pending and closed change requests

+
+
+ setSearchFilter(e.target.value)} + leftIcon={} + placeholder="Search change requests by author, environment slug or secret path..." + className="flex-1" + containerClassName="mb-4" + /> +
+
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" + )} + > + + {isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open +
+
setStatusFilter("close")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("close"); + }} + > + + {isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed +
+
- + - - Select an author - {members?.map(({ user, id }) => ( + + + Select an Environment + + {currentWorkspace?.environments.map(({ slug, name }) => ( - setCommitterFilter((state) => (state === user.id ? undefined : user.id)) - } - key={`request-filter-member-${id}`} - icon={ - committerFilter === user.id && - } + onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))} + key={`request-filter-${slug}`} + icon={envFilter === slug && } iconPos="right" > - {user.username} + {name} ))} + {!!permission.can( + ProjectPermissionMemberActions.Read, + ProjectPermissionSub.Member + ) && ( + + + + + + + Select an Author + + {members?.map(({ user, id }) => ( + + setCommitterFilter((state) => (state === user.id ? undefined : user.id)) + } + key={`request-filter-member-${id}`} + icon={ + committerFilter === user.id && + } + iconPos="right" + > + {user.username} + + ))} + + + )} +
+
+
+ {isRequestListEmpty && ( +
+ +
+ )} + {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 ( +
setSelectedApprovalId(secretApproval.id)} + onKeyDown={(evt) => { + if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id); + }} + > +
+ + {secretApproval.isReplicated + ? `${commits.length} secret pending import` + : generateCommitText(commits)} + #{secretApproval.slug} +
+ + Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} + {committerUser?.firstName || ""} {committerUser?.lastName || ""} ( + {committerUser?.email}) + {!isReviewed && status === "open" && " - Review required"} + +
+ ); + })} + {Boolean( + !filteredRequests.length && !isRequestListEmpty && !isApprovalRequestLoading + ) && } + {(isFetchingNextApprovalRequest || isApprovalRequestLoading) && ( +
+ {Array.apply(0, Array(3)).map((_x, index) => ( +
+
+ + +
+ +
+ ))} +
)}
-
-
- {isRequestListEmpty && ( -
- -
- )} - {secretApprovalRequests?.pages?.map((group, i) => ( - - {group?.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 ( -
setSelectedApprovalId(secretApproval.id)} - onKeyDown={(evt) => { - if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id); - }} - > -
- - {secretApproval.isReplicated - ? `${commits.length} secret pending import` - : generateCommitText(commits)} - #{secretApproval.slug} -
- - Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} - {committerUser?.firstName || ""} {committerUser?.lastName || ""} ( - {committerUser?.email}) - {!isReviewed && status === "open" && " - Review required"} - -
- ); - })} -
- ))} - {(isFetchingNextApprovalRequest || isApprovalRequestLoading) && ( -
- {Array.apply(0, Array(3)).map((_x, index) => ( -
-
- - -
- -
- ))} -
+ {hasNextApprovalPage && ( + )}
- {hasNextApprovalPage && ( - - )} )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index 89b427bb3..466a06c84 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -56,27 +56,24 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica if (score[CommitType.CREATE]) text.push( - {score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"} - created + {score[CommitType.CREATE]} Secret{score[CommitType.CREATE] !== 1 && "s"} + Created ); if (score[CommitType.UPDATE]) text.push( - {Boolean(text.length) && ","} - {score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"} - - {" "} - updated - + {Boolean(text.length) && ", "} + {score[CommitType.UPDATE]} Secret{score[CommitType.UPDATE] !== 1 && "s"} + Updated ); if (score[CommitType.DELETE]) text.push( {Boolean(text.length) && "and"} - {score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"} - deleted + {score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"} + Deleted ); return text;